Skip to content

Adding websocket heartbeat#333

Open
madAndroid wants to merge 2 commits intoactions:mainfrom
Daemon-Solutions:Add-Websocket-Heartbeat
Open

Adding websocket heartbeat#333
madAndroid wants to merge 2 commits intoactions:mainfrom
Daemon-Solutions:Add-Websocket-Heartbeat

Conversation

@madAndroid
Copy link
Copy Markdown

@madAndroid madAndroid commented Mar 31, 2026

@madAndroid madAndroid requested review from a team and nikola-jokic as code owners March 31, 2026 08:39
Copilot AI review requested due to automatic review settings April 22, 2026 13:55
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a WebSocket heartbeat around Kubernetes exec sessions to reduce premature termination / stalled connections in “kubernetes mode” runners (Issue #228).

Changes:

  • Introduces ping/pong heartbeat timers (configurable via env vars) for the exec WebSocket.
  • Adds WebSocket lifecycle cleanup (close-with-timeout) on success/failure paths.
  • Expands debug logging around execPodStep and heartbeat behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +261 to +268
const PING_PERIOD_MS = parseInt(
process.env.ACTIONS_RUNNER_HEARTBEAT_PERIOD_MS || '5000',
10
)
const PING_READ_DEADLINE_MS = parseInt(
process.env.ACTIONS_RUNNER_HEARTBEAT_DEADLINE_MS ||
String(PING_PERIOD_MS * 12 + 1000),
10
Comment on lines +310 to +315
pongTimeout = setTimeout(() => {
core.warning(
`[Heartbeat] No pong received in ${PING_READ_DEADLINE_MS}ms, connection may be stale`
)
}, PING_READ_DEADLINE_MS)
}
Comment on lines +253 to +258
core.debug(
`[execPodStep] Starting execPodStep with command: ${JSON.stringify(command)}, podName: ${podName}, containerName: ${containerName}`
)

command = fixArgs(command)
return await new Promise(function (resolve, reject) {
core.debug(`[execPodStep] Fixed command: ${JSON.stringify(command)}`)
Comment on lines +373 to +376
return new Promise<number>((resolve, reject) => {
core.debug('[execPodStep] About to call exec.exec')
let ws: any | null = null

Comment on lines +260 to +371
// Heartbeat constants matching kubectl's Go implementation
const PING_PERIOD_MS = parseInt(
process.env.ACTIONS_RUNNER_HEARTBEAT_PERIOD_MS || '5000',
10
)
const PING_READ_DEADLINE_MS = parseInt(
process.env.ACTIONS_RUNNER_HEARTBEAT_DEADLINE_MS ||
String(PING_PERIOD_MS * 12 + 1000),
10
)
core.debug(
`[execPodStep] Heartbeat config: PING_PERIOD_MS=${PING_PERIOD_MS}, PING_READ_DEADLINE_MS=${PING_READ_DEADLINE_MS}`
)

let pingInterval: ReturnType<typeof setTimeout> | null = null
let pongTimeout: ReturnType<typeof setTimeout> | null = null
let lastHeartbeatLog = 0
const HEARTBEAT_LOG_INTERVAL_MS = 2 * 60 * 1000 // 2 minutes

const shouldLogHeartbeat = (): boolean => {
const now = Date.now()
if (now - lastHeartbeatLog >= HEARTBEAT_LOG_INTERVAL_MS) {
lastHeartbeatLog = now
return true
}
return false
}

const stopHeartbeat = (): void => {
if (shouldLogHeartbeat()) {
core.debug('[Heartbeat] stopHeartbeat called')
}
if (pingInterval) {
clearInterval(pingInterval)
pingInterval = null
}
if (pongTimeout) {
clearTimeout(pongTimeout)
pongTimeout = null
}
}

const resetPongTimeout = (): void => {
if (shouldLogHeartbeat()) {
core.debug('[Heartbeat] resetPongTimeout called')
}
if (pongTimeout) {
clearTimeout(pongTimeout)
pongTimeout = null
}
pongTimeout = setTimeout(() => {
core.warning(
`[Heartbeat] No pong received in ${PING_READ_DEADLINE_MS}ms, connection may be stale`
)
}, PING_READ_DEADLINE_MS)
}

const startHeartbeat = (ws: any): void => {
core.debug(
`[Heartbeat] Starting with period=${PING_PERIOD_MS}ms, deadline=${PING_READ_DEADLINE_MS}ms`
)
lastHeartbeatLog = Date.now() // Initialize timer

// Handle pong responses
ws.on('pong', () => {
if (shouldLogHeartbeat()) {
core.debug('[Heartbeat] Pong received')
}
resetPongTimeout()
})

// Handle errors
ws.on('error', (err: Error) => {
core.error(`[Heartbeat] WebSocket error: ${err.message}`)
stopHeartbeat()
})

// Cleanup on close
ws.on('close', () => {
core.debug('[Heartbeat] WebSocket closed, stopping heartbeat')
stopHeartbeat()
})

// Set initial pong timeout
resetPongTimeout()

// Start ping loop
pingInterval = setInterval(() => {
// WebSocket readyState: 0 = CONNECTING, 1 = OPEN, 2 = CLOSING, 3 = CLOSED
if (shouldLogHeartbeat()) {
core.debug(`[Heartbeat] Ping loop, ws.readyState=${ws.readyState}`)
}
if (ws.readyState === 1) {
try {
ws.ping()
if (shouldLogHeartbeat()) {
core.debug('[Heartbeat] Ping sent')
}
} catch (err) {
core.error(`[Heartbeat] Ping failed: ${err}`)
stopHeartbeat()
}
} else {
if (shouldLogHeartbeat()) {
core.debug(
`[Heartbeat] WebSocket not open (readyState=${ws.readyState}), stopping`
)
}
stopHeartbeat()
}
}, PING_PERIOD_MS)
}
Comment on lines +347 to +369
pingInterval = setInterval(() => {
// WebSocket readyState: 0 = CONNECTING, 1 = OPEN, 2 = CLOSING, 3 = CLOSED
if (shouldLogHeartbeat()) {
core.debug(`[Heartbeat] Ping loop, ws.readyState=${ws.readyState}`)
}
if (ws.readyState === 1) {
try {
ws.ping()
if (shouldLogHeartbeat()) {
core.debug('[Heartbeat] Ping sent')
}
} catch (err) {
core.error(`[Heartbeat] Ping failed: ${err}`)
stopHeartbeat()
}
} else {
if (shouldLogHeartbeat()) {
core.debug(
`[Heartbeat] WebSocket not open (readyState=${ws.readyState}), stopping`
)
}
stopHeartbeat()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants